--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Commit 06710bcd69cd22e9660aa555b44e7d1ca87d2d60
Parents : 83db6ce
Author : Ivan <ivan@quad4.io>
Signature : Signature validation error
Date : 2026-05-06T16:32:21-05:00
feat(lxmf): update conversation previews to include image, audio, and file attachment notifications and add related tests
Changes
15 files changed, 355 insertions(+), 9 deletions(-)
Diff
diff --git a/meshchatx/src/backend/lxmf_utils.py b/meshchatx/src/backend/lxmf_utils.py
index a59207c2..9256b3e1 100644
--- a/meshchatx/src/backend/lxmf_utils.py
+++ b/meshchatx/src/backend/lxmf_utils.py
@@ -152,7 +152,7 @@ def lxmf_sidebar_preview_for_conversation_latest_row(
local_hash: str,
peer_display_name: str,
) -> str:
- """Single-line preview for conversation list APIs (reactions have empty body)."""
+ """Single-line preview for conversation list APIs (reactions and some media have empty body)."""
content = row.get("content")
if content is not None and str(content).strip():
return str(content)
@@ -202,6 +202,29 @@ def lxmf_sidebar_preview_for_conversation_latest_row(
return f"{actor} requested your location"
return f"{actor} sent a location request"
+ image = fields.get("image")
+ if isinstance(image, dict) and image:
+ if actor == "You":
+ return "You sent an image"
+ return f"{actor} sent an image"
+
+ audio = fields.get("audio")
+ if isinstance(audio, dict) and audio:
+ if actor == "You":
+ return "You sent a voice note"
+ return f"{actor} sent a voice note"
+
+ file_attachments = fields.get("file_attachments")
+ if isinstance(file_attachments, list) and len(file_attachments) > 0:
+ n = len(file_attachments)
+ if n == 1:
+ if actor == "You":
+ return "You sent a file"
+ return f"{actor} sent a file"
+ if actor == "You":
+ return f"You sent {n} files"
+ return f"{actor} sent {n} files"
+
return str(content or "")
diff --git a/meshchatx/src/frontend/components/messages/ConversationPeerHeader.vue b/meshchatx/src/frontend/components/messages/ConversationPeerHeader.vue
index a5df386f..f2c6cbf9 100644
--- a/meshchatx/src/frontend/components/messages/ConversationPeerHeader.vue
+++ b/meshchatx/src/frontend/components/messages/ConversationPeerHeader.vue
@@ -48,7 +48,8 @@
v-if="
selectedPeerPath ||
selectedPeerSignalMetrics?.snr != null ||
- selectedPeerLxmfStampInfo?.stamp_cost
+ selectedPeerLxmfStampInfo?.stamp_cost ||
+ lxmfHasOutboundTicket
"
class="flex items-center gap-2 min-w-0"
>
@@ -77,9 +78,30 @@
>
</span>
- <span v-if="selectedPeerLxmfStampInfo?.stamp_cost" class="flex items-center gap-2 shrink-0">
+ <span
+ v-if="selectedPeerLxmfStampInfo?.stamp_cost || lxmfHasOutboundTicket"
+ class="flex items-center gap-1 shrink-0"
+ >
<span class="text-gray-300 dark:text-zinc-700 opacity-50">•</span>
+ <MaterialDesignIcon
+ v-if="lxmfHasOutboundTicket"
+ icon-name="ticket-confirmation"
+ class="size-3.5 shrink-0"
+ :class="
+ lxmfStampTicketValid
+ ? 'text-emerald-600 dark:text-emerald-400'
+ : 'text-amber-600 dark:text-amber-500'
+ "
+ :title="
+ lxmfStampTicketValid
+ ? $t('messages.stamp_ticket_valid', {
+ expires: lxmfStampTicketExpiresRelative,
+ })
+ : $t('messages.stamp_ticket_expired')
+ "
+ />
<span
+ v-if="selectedPeerLxmfStampInfo?.stamp_cost"
class="cursor-pointer hover:text-gray-700 dark:hover:text-zinc-200"
title="LXMF stamp requirement"
@click="$emit('stamp-info-click', selectedPeerLxmfStampInfo)"
@@ -115,11 +137,15 @@
<script>
import Utils from "../../js/Utils";
+import dayjs from "dayjs";
+import relativeTime from "dayjs/plugin/relativeTime";
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
import IconButton from "../IconButton.vue";
import LxmfUserIcon from "../LxmfUserIcon.vue";
import ConversationDropDownMenu from "./ConversationDropDownMenu.vue";
+dayjs.extend(relativeTime);
+
export default {
name: "ConversationPeerHeader",
components: {
@@ -176,6 +202,34 @@ export default {
destinationDisplay() {
return Utils.formatDestinationHash(this.selectedPeer?.destination_hash);
},
+ lxmfHasOutboundTicket() {
+ return this.selectedPeerLxmfStampInfo?.outbound_ticket_expiry != null;
+ },
+ lxmfStampTicketExpiryMs() {
+ const e = this.selectedPeerLxmfStampInfo?.outbound_ticket_expiry;
+ if (e == null) {
+ return null;
+ }
+ const n = Number(e);
+ if (!Number.isFinite(n)) {
+ return null;
+ }
+ return n * 1000;
+ },
+ lxmfStampTicketValid() {
+ const ms = this.lxmfStampTicketExpiryMs;
+ if (ms == null) {
+ return false;
+ }
+ return ms > Date.now();
+ },
+ lxmfStampTicketExpiresRelative() {
+ const ms = this.lxmfStampTicketExpiryMs;
+ if (ms == null) {
+ return "";
+ }
+ return dayjs(ms).fromNow();
+ },
},
};
</script>
diff --git a/meshchatx/src/frontend/components/messages/ConversationViewer.vue b/meshchatx/src/frontend/components/messages/ConversationViewer.vue
index 1eacb808..f55ce805 100644
--- a/meshchatx/src/frontend/components/messages/ConversationViewer.vue
+++ b/meshchatx/src/frontend/components/messages/ConversationViewer.vue
@@ -3130,9 +3130,7 @@ export default {
}
);
- // do nothing if response is for a previous request
if (seq !== this.lxmfMessagesRequestSequence) {
- console.log("ignoring response for previous lxmf messages request");
return;
}
@@ -4153,12 +4151,35 @@ export default {
lxmfImageUrl(hash) {
return `/api/v1/lxmf-messages/attachment/${hash}/image`;
},
+ lxmfDataUrlFromOutboundJobImage(img) {
+ if (!img?.image_bytes || typeof img.image_bytes !== "string") {
+ return null;
+ }
+ const raw = (img.image_type || "png").toLowerCase().replace(/^image\//, "");
+ let mime = "image/png";
+ if (raw === "jpg" || raw === "jpeg") {
+ mime = "image/jpeg";
+ } else if (raw === "png" || raw === "gif" || raw === "webp" || raw === "bmp") {
+ mime = `image/${raw}`;
+ } else if (raw === "webm") {
+ mime = "video/webm";
+ } else if (raw === "svg" || raw === "svg+xml") {
+ mime = "image/svg+xml";
+ } else {
+ mime = `image/${raw}`;
+ }
+ return `data:${mime};base64,${img.image_bytes}`;
+ },
pendingOutboundImageSrc(chatItem) {
const prev = chatItem.lxmf_message?.fields?.image?._preview_url;
if (prev) {
return prev;
}
- return this.lxmfImageUrl(chatItem.lxmf_message.hash);
+ const h = chatItem.lxmf_message?.hash;
+ if (typeof h === "string" && h.startsWith("pending-")) {
+ return "";
+ }
+ return this.lxmfImageUrl(h);
},
removePendingOutboundPlaceholder(hash) {
if (!hash) {
@@ -4836,10 +4857,12 @@ export default {
job.pendingHash = pendingHash;
const pendingFields = {};
if (job.images.length > 0) {
+ const previewUrl =
+ job.imagePreviewUrls[0] || this.lxmfDataUrlFromOutboundJobImage(job.images[0]);
pendingFields.image = {
image_type: job.images[0].image_type,
image_size: job.images[0].image_size,
- _preview_url: job.imagePreviewUrls[0],
+ _preview_url: previewUrl,
};
}
this.chatItems.push({
diff --git a/meshchatx/src/frontend/js/lxmfConversationPreview.js b/meshchatx/src/frontend/js/lxmfConversationPreview.js
index 360d0748..ff96c57c 100644
--- a/meshchatx/src/frontend/js/lxmfConversationPreview.js
+++ b/meshchatx/src/frontend/js/lxmfConversationPreview.js
@@ -1,7 +1,8 @@
/**
* One-line preview text for the conversation list / sidebar (LXMF latest row).
- * Handles plain content, Columba reactions, location/telemetry fields, and
- * Sideband location-request commands. Pairs with the Python helper
+ * Handles plain content, Columba reactions, location/telemetry fields,
+ * Sideband location-request commands, and media-only payloads (image, audio,
+ * file attachments). Pairs with the Python helper
* lxmf_sidebar_preview_for_conversation_latest_row.
*/
@@ -102,5 +103,41 @@ export function lxmfConversationListPreview(msg, { myLxmfAddressHash, peerDispla
return msg?.is_incoming ? `${name} requested your location` : `${name} sent a location request`;
}
+ const imageField = fields?.image;
+ if (imageField && typeof imageField === "object" && Object.keys(imageField).length > 0) {
+ const fromSelf = isOutboundFromSelf(msg, myLxmfAddressHash);
+ if (typeof t === "function") {
+ return fromSelf ? t("messages.conversation_image_you") : t("messages.conversation_image_other", { name });
+ }
+ return fromSelf ? "You sent an image" : `${name} sent an image`;
+ }
+
+ const audioField = fields?.audio;
+ if (audioField && typeof audioField === "object" && Object.keys(audioField).length > 0) {
+ const fromSelf = isOutboundFromSelf(msg, myLxmfAddressHash);
+ if (typeof t === "function") {
+ return fromSelf ? t("messages.conversation_voice_you") : t("messages.conversation_voice_other", { name });
+ }
+ return fromSelf ? "You sent a voice note" : `${name} sent a voice note`;
+ }
+
+ const files = fields?.file_attachments;
+ if (Array.isArray(files) && files.length > 0) {
+ const fromSelf = isOutboundFromSelf(msg, myLxmfAddressHash);
+ const n = files.length;
+ if (typeof t === "function") {
+ if (n === 1) {
+ return fromSelf ? t("messages.conversation_file_you") : t("messages.conversation_file_other", { name });
+ }
+ return fromSelf
+ ? t("messages.conversation_files_you", { count: n })
+ : t("messages.conversation_files_other", { name, count: n });
+ }
+ if (n === 1) {
+ return fromSelf ? "You sent a file" : `${name} sent a file`;
+ }
+ return fromSelf ? `You sent ${n} files` : `${name} sent ${n} files`;
+ }
+
return raw ?? "";
}
diff --git a/meshchatx/src/frontend/locales/de.json b/meshchatx/src/frontend/locales/de.json
index a6bab0b5..272c565c 100644
--- a/meshchatx/src/frontend/locales/de.json
+++ b/meshchatx/src/frontend/locales/de.json
@@ -1175,6 +1175,8 @@
"hops_away": "{count} Hops entfernt",
"snr": "SNR {snr}",
"stamp_cost": "Stempelkosten {cost}",
+ "stamp_ticket_valid": "Ausgehendes Stempel-Ticket aktiv (sofortiger Versand). Läuft ab {expires}.",
+ "stamp_ticket_expired": "Stempel-Ticket abgelaufen; die nächste Nachricht kann einen Proof-of-Work erfordern.",
"pop_out_chat": "Chat auslagern",
"custom_display_name": "Benutzerdefinierter Anzeigename",
"stranger_banner_text": "Dieser Peer ist nicht in Ihren Kontakten. Anhänge von Fremden werden blockiert.",
@@ -1379,6 +1381,14 @@
"conversation_telemetry_stream_preview": "{name} hat einen Telemetrie-Datenstrom gesendet",
"conversation_location_request_in_preview": "{name} hat den Standort angefordert",
"conversation_location_request_out_preview": "{name} hat eine Standortanfrage gesendet",
+ "conversation_image_you": "Du hast ein Bild gesendet",
+ "conversation_image_other": "{name} hat ein Bild gesendet",
+ "conversation_voice_you": "Du hast eine Sprachnachricht gesendet",
+ "conversation_voice_other": "{name} hat eine Sprachnachricht gesendet",
+ "conversation_file_you": "Du hast eine Datei gesendet",
+ "conversation_file_other": "{name} hat eine Datei gesendet",
+ "conversation_files_you": "Du hast {count} Dateien gesendet",
+ "conversation_files_other": "{name} hat {count} Dateien gesendet",
"message_not_found_in_cache": "Nachricht nicht im Cache gefunden"
},
"nomadnet": {
diff --git a/meshchatx/src/frontend/locales/en.json b/meshchatx/src/frontend/locales/en.json
index e49ece29..ceca8178 100644
--- a/meshchatx/src/frontend/locales/en.json
+++ b/meshchatx/src/frontend/locales/en.json
@@ -1123,6 +1123,8 @@
"hops_away": "{count} hops away",
"snr": "SNR {snr}",
"stamp_cost": "Stamp Cost {cost}",
+ "stamp_ticket_valid": "Outbound stamp ticket active (instant send). Expires {expires}.",
+ "stamp_ticket_expired": "Stamp ticket expired; the next message may require proof-of-work.",
"pop_out_chat": "Pop out chat",
"more_actions": "More actions",
"retry_failed": "Retry failed messages",
@@ -1327,6 +1329,14 @@
"conversation_telemetry_stream_preview": "{name} sent a telemetry stream",
"conversation_location_request_in_preview": "{name} requested your location",
"conversation_location_request_out_preview": "{name} sent a location request",
+ "conversation_image_you": "You sent an image",
+ "conversation_image_other": "{name} sent an image",
+ "conversation_voice_you": "You sent a voice note",
+ "conversation_voice_other": "{name} sent a voice note",
+ "conversation_file_you": "You sent a file",
+ "conversation_file_other": "{name} sent a file",
+ "conversation_files_you": "You sent {count} files",
+ "conversation_files_other": "{name} sent {count} files",
"message_not_found_in_cache": "Message not found in cache"
},
"settings": {
diff --git a/meshchatx/src/frontend/locales/es.json b/meshchatx/src/frontend/locales/es.json
index 1a378a48..a65b8632 100644
--- a/meshchatx/src/frontend/locales/es.json
+++ b/meshchatx/src/frontend/locales/es.json
@@ -1123,6 +1123,8 @@
"hops_away": "{count}salta lejos",
"snr": "SNR{snr}",
"stamp_cost": "Costo de sello{cost}",
+ "stamp_ticket_valid": "Boleto de sello saliente activo (envío instantáneo). Caduca {expires}.",
+ "stamp_ticket_expired": "Boleto de sello caducado; el próximo mensaje puede requerir prueba de trabajo.",
"pop_out_chat": "Apágalo.",
"more_actions": "Más acciones",
"retry_failed": "Retry falló mensajes",
@@ -1327,6 +1329,14 @@
"conversation_telemetry_stream_preview": "{name} envió un flujo de telemetría",
"conversation_location_request_in_preview": "{name} solicitó tu ubicación",
"conversation_location_request_out_preview": "{name} envió una solicitud de ubicación",
+ "conversation_image_you": "Enviaste una imagen",
+ "conversation_image_other": "{name} envió una imagen",
+ "conversation_voice_you": "Enviaste una nota de voz",
+ "conversation_voice_other": "{name} envió una nota de voz",
+ "conversation_file_you": "Enviaste un archivo",
+ "conversation_file_other": "{name} envió un archivo",
+ "conversation_files_you": "Enviaste {count} archivos",
+ "conversation_files_other": "{name} envió {count} archivos",
"message_not_found_in_cache": "Mensaje no encontrado en caché"
},
"settings": {
diff --git a/meshchatx/src/frontend/locales/fr.json b/meshchatx/src/frontend/locales/fr.json
index 4f8e03bd..17b3a3c7 100644
--- a/meshchatx/src/frontend/locales/fr.json
+++ b/meshchatx/src/frontend/locales/fr.json
@@ -1123,6 +1123,8 @@
"hops_away": "{count}houblon loin",
"snr": "SNR{snr}",
"stamp_cost": "Coût du timbre{cost}",
+ "stamp_ticket_valid": "Ticket de timbre sortant actif (envoi instantané). Expire {expires}.",
+ "stamp_ticket_expired": "Ticket de timbre expiré ; le prochain message peut nécessiter une preuve de travail.",
"pop_out_chat": "Clavardage",
"more_actions": "Autres actions",
"retry_failed": "Réessayer les messages échoués",
@@ -1327,6 +1329,14 @@
"conversation_telemetry_stream_preview": "{name} a envoyé un flux de télémétrie",
"conversation_location_request_in_preview": "{name} a demandé votre position",
"conversation_location_request_out_preview": "{name} a envoyé une demande de position",
+ "conversation_image_you": "Vous avez envoyé une image",
+ "conversation_image_other": "{name} a envoyé une image",
+ "conversation_voice_you": "Vous avez envoyé une note vocale",
+ "conversation_voice_other": "{name} a envoyé une note vocale",
+ "conversation_file_you": "Vous avez envoyé un fichier",
+ "conversation_file_other": "{name} a envoyé un fichier",
+ "conversation_files_you": "Vous avez envoyé {count} fichiers",
+ "conversation_files_other": "{name} a envoyé {count} fichiers",
"message_not_found_in_cache": "Message non trouvé dans cache"
},
"settings": {
diff --git a/meshchatx/src/frontend/locales/it.json b/meshchatx/src/frontend/locales/it.json
index 33baf649..29ed0b45 100644
--- a/meshchatx/src/frontend/locales/it.json
+++ b/meshchatx/src/frontend/locales/it.json
@@ -1175,6 +1175,8 @@
"hops_away": "{count} salti di distanza",
"snr": "SNR {snr}",
"stamp_cost": "Costo Francobollo {cost}",
+ "stamp_ticket_valid": "Biglietto di francobollo in uscita attivo (invio istantaneo). Scade {expires}.",
+ "stamp_ticket_expired": "Biglietto scaduto; il prossimo messaggio potrebbe richiedere proof-of-work.",
"pop_out_chat": "Estrai chat",
"custom_display_name": "Nome Visualizzato Personalizzato",
"stranger_banner_text": "Questo peer non è nei tuoi contatti. Gli allegati da sconosciuti sono bloccati.",
@@ -1379,6 +1381,14 @@
"conversation_telemetry_stream_preview": "{name} ha inviato un flusso di telemetria",
"conversation_location_request_in_preview": "{name} ha richiesto la tua posizione",
"conversation_location_request_out_preview": "{name} ha inviato una richiesta di posizione",
+ "conversation_image_you": "Hai inviato un'immagine",
+ "conversation_image_other": "{name} ha inviato un'immagine",
+ "conversation_voice_you": "Hai inviato una nota vocale",
+ "conversation_voice_other": "{name} ha inviato una nota vocale",
+ "conversation_file_you": "Hai inviato un file",
+ "conversation_file_other": "{name} ha inviato un file",
+ "conversation_files_you": "Hai inviato {count} file",
+ "conversation_files_other": "{name} ha inviato {count} file",
"message_not_found_in_cache": "Messaggio non trovato nella cache"
},
"settings": {
diff --git a/meshchatx/src/frontend/locales/nl.json b/meshchatx/src/frontend/locales/nl.json
index 21f32409..74958322 100644
--- a/meshchatx/src/frontend/locales/nl.json
+++ b/meshchatx/src/frontend/locales/nl.json
@@ -1123,6 +1123,8 @@
"hops_away": "{count}hop weg",
"snr": "SNR{snr}",
"stamp_cost": "Postzegelkosten{cost}",
+ "stamp_ticket_valid": "Uitgaand postzegelticket actief (direct verzenden). Verloopt {expires}.",
+ "stamp_ticket_expired": "Postzegelticket verlopen; het volgende bericht kan proof-of-work vereisen.",
"pop_out_chat": "Pop out chat",
"more_actions": "Meer acties",
"retry_failed": "Opnieuw proberen van mislukte berichten",
@@ -1327,6 +1329,14 @@
"conversation_telemetry_stream_preview": "{name} stuurde een telemetriestroom",
"conversation_location_request_in_preview": "{name} vroeg om jouw locatie",
"conversation_location_request_out_preview": "{name} stuurde een locatieverzoek",
+ "conversation_image_you": "Je hebt een afbeelding gestuurd",
+ "conversation_image_other": "{name} heeft een afbeelding gestuurd",
+ "conversation_voice_you": "Je hebt een spraakbericht gestuurd",
+ "conversation_voice_other": "{name} heeft een spraakbericht gestuurd",
+ "conversation_file_you": "Je hebt een bestand gestuurd",
+ "conversation_file_other": "{name} heeft een bestand gestuurd",
+ "conversation_files_you": "Je hebt {count} bestanden gestuurd",
+ "conversation_files_other": "{name} heeft {count} bestanden gestuurd",
"message_not_found_in_cache": "Bericht niet gevonden in cache"
},
"settings": {
diff --git a/meshchatx/src/frontend/locales/ru.json b/meshchatx/src/frontend/locales/ru.json
index 72c5b96d..7895b311 100644
--- a/meshchatx/src/frontend/locales/ru.json
+++ b/meshchatx/src/frontend/locales/ru.json
@@ -1175,6 +1175,8 @@
"hops_away": "в {count} прыжках",
"snr": "SNR {snr}",
"stamp_cost": "Стоимость штампа {cost}",
+ "stamp_ticket_valid": "Активный исходящий билет штампа (мгновенная отправка). Истекает {expires}.",
+ "stamp_ticket_expired": "Билет штампа истёк; следующее сообщение может потребовать proof-of-work.",
"pop_out_chat": "Открыть в отдельном окне",
"custom_display_name": "Свое имя для контакта",
"stranger_banner_text": "Этот узел не в ваших контактах. Вложения от незнакомцев заблокированы.",
@@ -1379,6 +1381,14 @@
"conversation_telemetry_stream_preview": "{name} отправил(а) поток телеметрии",
"conversation_location_request_in_preview": "{name} запросил(а) ваше местоположение",
"conversation_location_request_out_preview": "{name} отправил(а) запрос местоположения",
+ "conversation_image_you": "Вы отправили изображение",
+ "conversation_image_other": "{name} отправил(а) изображение",
+ "conversation_voice_you": "Вы отправили голосовое сообщение",
+ "conversation_voice_other": "{name} отправил(а) голосовое сообщение",
+ "conversation_file_you": "Вы отправили файл",
+ "conversation_file_other": "{name} отправил(а) файл",
+ "conversation_files_you": "Вы отправили файлов: {count}",
+ "conversation_files_other": "{name} отправил(а) файлов: {count}",
"message_not_found_in_cache": "Сообщение не найдено в кэше"
},
"nomadnet": {
diff --git a/meshchatx/src/frontend/locales/zh.json b/meshchatx/src/frontend/locales/zh.json
index 0879588b..5fbe0462 100644
--- a/meshchatx/src/frontend/locales/zh.json
+++ b/meshchatx/src/frontend/locales/zh.json
@@ -1123,6 +1123,8 @@
"hops_away": "{count} 跳远",
"snr": "SNR {snr} dB",
"stamp_cost": "邮戳成本 {cost}",
+ "stamp_ticket_valid": "出站邮戳票据有效(即时发送)。将于 {expires} 过期。",
+ "stamp_ticket_expired": "邮戳票据已过期;下一条消息可能需要工作量证明。",
"pop_out_chat": "弹出聊天",
"more_actions": "更多操作",
"retry_failed": "重试失败的消息",
@@ -1327,6 +1329,14 @@
"conversation_telemetry_stream_preview": "{name} 发送了遥测流",
"conversation_location_request_in_preview": "{name} 请求你的位置",
"conversation_location_request_out_preview": "{name} 发送了位置请求",
+ "conversation_image_you": "你发送了一张图片",
+ "conversation_image_other": "{name} 发送了一张图片",
+ "conversation_voice_you": "你发送了一条语音",
+ "conversation_voice_other": "{name} 发送了一条语音",
+ "conversation_file_you": "你发送了一个文件",
+ "conversation_file_other": "{name} 发送了一个文件",
+ "conversation_files_you": "你发送了 {count} 个文件",
+ "conversation_files_other": "{name} 发送了 {count} 个文件",
"message_not_found_in_cache": "缓存中未找到消息"
},
"settings": {
diff --git a/tests/backend/test_lxmf_utils_extended.py b/tests/backend/test_lxmf_utils_extended.py
index a8246269..eaee9a9d 100644
--- a/tests/backend/test_lxmf_utils_extended.py
+++ b/tests/backend/test_lxmf_utils_extended.py
@@ -416,3 +416,71 @@ def test_sidebar_preview_telemetry_stream():
peer_display_name="Jordan",
)
assert out == "Jordan sent a telemetry stream"
+
+
+def test_sidebar_preview_image_incoming():
+ row = {
+ "content": "",
+ "fields": json.dumps({"image": {"image_type": "png", "image_size": 12}}),
+ "is_incoming": 1,
+ "source_hash": "b" * 32,
+ }
+ out = lxmf_sidebar_preview_for_conversation_latest_row(
+ row,
+ local_hash="a" * 32,
+ peer_display_name="Quinn",
+ )
+ assert out == "Quinn sent an image"
+
+
+def test_sidebar_preview_image_outbound_you():
+ me = "c" * 32
+ row = {
+ "content": "",
+ "fields": json.dumps({"image": {"image_type": "jpeg", "image_size": 99}}),
+ "is_incoming": 0,
+ "source_hash": me,
+ }
+ out = lxmf_sidebar_preview_for_conversation_latest_row(
+ row,
+ local_hash=me,
+ peer_display_name="Pat",
+ )
+ assert out == "You sent an image"
+
+
+def test_sidebar_preview_audio_voice_note():
+ row = {
+ "content": "",
+ "fields": json.dumps({"audio": {"audio_mode": "opus", "audio_size": 500}}),
+ "is_incoming": 1,
+ "source_hash": "b" * 32,
+ }
+ out = lxmf_sidebar_preview_for_conversation_latest_row(
+ row,
+ local_hash="a" * 32,
+ peer_display_name="Morgan",
+ )
+ assert out == "Morgan sent a voice note"
+
+
+def test_sidebar_preview_file_attachments_plural():
+ row = {
+ "content": "",
+ "fields": json.dumps(
+ {
+ "file_attachments": [
+ {"file_name": "a.txt", "file_size": 1},
+ {"file_name": "b.txt", "file_size": 2},
+ ],
+ },
+ ),
+ "is_incoming": 0,
+ "source_hash": "d" * 32,
+ }
+ out = lxmf_sidebar_preview_for_conversation_latest_row(
+ row,
+ local_hash="d" * 32,
+ peer_display_name="Casey",
+ )
+ assert out == "You sent 2 files"
diff --git a/tests/backend/test_notification_user_facing_filter.py b/tests/backend/test_notification_user_facing_filter.py
index 714a6a1d..607499c3 100644
--- a/tests/backend/test_notification_user_facing_filter.py
+++ b/tests/backend/test_notification_user_facing_filter.py
@@ -653,6 +653,10 @@ class TestNotificationsGetUserFacingFilter:
body = await self._get(bell_app, unread="true", limit=10)
assert body["unread_count"] == 1
assert len(body["notifications"]) == 1
+ peer_label = f"peer-{PEER_HASH[:6]}"
+ assert body["notifications"][0]["latest_message_preview"] == (
+ f"{peer_label} sent an image"
+ )
async def test_badge_count_matches_dropdown_items(self, bell_app):
# Mix of user-facing and silent messages across multiple peers must
diff --git a/tests/frontend/lxmfConversationPreview.test.js b/tests/frontend/lxmfConversationPreview.test.js
index eae2133a..6a4cfa08 100644
--- a/tests/frontend/lxmfConversationPreview.test.js
+++ b/tests/frontend/lxmfConversationPreview.test.js
@@ -96,4 +96,61 @@ describe("lxmfConversationListPreview", () => {
);
expect(s).toBe("Riley requested your location");
});
+
+ it("shows image preview when body is empty", () => {
+ const s = lxmfConversationListPreview(
+ {
+ content: "",
+ is_incoming: true,
+ source_hash: peer,
+ fields: { image: { image_type: "png", image_size: 10 } },
+ },
+ { myLxmfAddressHash: me, peerDisplayName: "Jo" }
+ );
+ expect(s).toBe("Jo sent an image");
+ });
+
+ it("shows outbound image preview as You", () => {
+ const s = lxmfConversationListPreview(
+ {
+ content: "",
+ is_incoming: false,
+ source_hash: me,
+ fields: { image: { image_type: "webp", image_size: 20 } },
+ },
+ { myLxmfAddressHash: me, peerDisplayName: "Jo" }
+ );
+ expect(s).toBe("You sent an image");
+ });
+
+ it("shows voice note preview for audio field", () => {
+ const s = lxmfConversationListPreview(
+ {
+ content: "",
+ is_incoming: true,
+ source_hash: peer,
+ fields: { audio: { audio_mode: "opus", audio_size: 100 } },
+ },
+ { myLxmfAddressHash: me, peerDisplayName: "Max" }
+ );
+ expect(s).toBe("Max sent a voice note");
+ });
+
+ it("shows multiple file attachment preview", () => {
+ const s = lxmfConversationListPreview(
+ {
+ content: "",
+ is_incoming: false,
+ source_hash: me,
+ fields: {
+ file_attachments: [
+ { file_name: "a.bin", file_size: 1 },
+ { file_name: "b.bin", file_size: 2 },
+ ],
+ },
+ },
+ { myLxmfAddressHash: me, peerDisplayName: "Jo" }
+ );
+ expect(s).toBe("You sent 2 files");
+ });
});
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────